From ed86f7ef012fb558fe8a8974e1e162ce75cbfd15 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
Date: Tue, 26 Aug 2025 15:47:05 +0300
Subject: feat(mobile): Retheme the mobile app (#1872)

* Add nativewindui

* migrate to nativewindui text

* Replace buttons with nativewindui buttons

* Use nativewindui search input

* fix the divider color

* More changes

* fix manage tag icon

* fix styling of bookmark card

* fix ios compilation

* fix search clear

* fix tag pill border color

* Store theme setting in app settings

* fix setting color appearance

* fix coloring of search input

* fix following system theme

* add a save button to info

* fix the grey colors on android

* fix icon active tint color

* drop the use of TextField
---
 .../mobile/app/dashboard/bookmarks/[slug]/info.tsx | 287 +++++++++++++--------
 1 file changed, 173 insertions(+), 114 deletions(-)

(limited to 'apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx')

diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
index af124160..1781ec74 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
@@ -1,26 +1,22 @@
 import React from "react";
+import { Alert, Pressable, View } from "react-native";
 import {
-  Alert,
-  Keyboard,
-  Pressable,
-  Text,
-  TouchableWithoutFeedback,
-  View,
-} from "react-native";
-import Animated, {
-  useAnimatedKeyboard,
-  useAnimatedStyle,
-} from "react-native-reanimated";
+  KeyboardAwareScrollView,
+  KeyboardGestureArea,
+} from "react-native-keyboard-controller";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
 import { router, Stack, useLocalSearchParams } from "expo-router";
 import TagPill from "@/components/bookmarks/TagPill";
 import FullPageError from "@/components/FullPageError";
 import { Button } from "@/components/ui/Button";
+import ChevronRight from "@/components/ui/ChevronRight";
 import { Divider } from "@/components/ui/Divider";
 import FullPageSpinner from "@/components/ui/FullPageSpinner";
 import { Input } from "@/components/ui/Input";
 import { Skeleton } from "@/components/ui/Skeleton";
+import { Text } from "@/components/ui/Text";
 import { useToast } from "@/components/ui/Toast";
-import { ChevronRight } from "lucide-react-native";
+import { cn } from "@/lib/utils";
 
 import {
   useAutoRefreshingBookmarkQuery,
@@ -30,9 +26,21 @@ import {
 import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
 import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils";
 
+function InfoSection({
+  className,
+  ...props
+}: React.ComponentProps<typeof View>) {
+  return (
+    <View
+      className={cn("flex gap-2 rounded-lg bg-card p-3", className)}
+      {...props}
+    />
+  );
+}
+
 function TagList({ bookmark }: { bookmark: ZBookmark }) {
   return (
-    <View className="flex gap-2 rounded-lg bg-white py-3 dark:bg-accent">
+    <InfoSection>
       {isBookmarkStillTagging(bookmark) ? (
         <View className="flex gap-4 pb-3">
           <Skeleton className="h-4 w-full" />
@@ -41,7 +49,7 @@ function TagList({ bookmark }: { bookmark: ZBookmark }) {
       ) : (
         bookmark.tags.length > 0 && (
           <>
-            <View className="flex flex-row flex-wrap gap-2 rounded-lg bg-background p-2">
+            <View className="flex flex-row flex-wrap gap-2 rounded-lg p-2">
               {bookmark.tags.map((t) => (
                 <TagPill key={t.id} tag={t} />
               ))}
@@ -50,94 +58,104 @@ function TagList({ bookmark }: { bookmark: ZBookmark }) {
           </>
         )
       )}
-      <Pressable
-        onPress={() =>
-          router.push(`/dashboard/bookmarks/${bookmark.id}/manage_tags`)
-        }
-        className="flex w-full flex-row justify-between gap-3 px-4"
-      >
-        <Text className="text-lg text-accent-foreground">Manage Tags</Text>
-        <ChevronRight color="rgb(0, 122, 255)" />
-      </Pressable>
-    </View>
+      <View>
+        <Pressable
+          onPress={() =>
+            router.push(`/dashboard/bookmarks/${bookmark.id}/manage_tags`)
+          }
+          className="flex w-full flex-row justify-between gap-3"
+        >
+          <Text>Manage Tags</Text>
+          <ChevronRight />
+        </Pressable>
+      </View>
+    </InfoSection>
   );
 }
 
 function ManageLists({ bookmark }: { bookmark: ZBookmark }) {
   return (
-    <View className="flex gap-4">
-      <Pressable
-        onPress={() =>
-          router.push(`/dashboard/bookmarks/${bookmark.id}/manage_lists`)
-        }
-        className="flex w-full flex-row justify-between gap-3 rounded-lg bg-white px-4 py-2 dark:bg-accent"
-      >
-        <Text className="text-lg text-accent-foreground">Manage Lists</Text>
-        <ChevronRight color="rgb(0, 122, 255)" />
-      </Pressable>
-    </View>
+    <InfoSection>
+      <View>
+        <Pressable
+          onPress={() =>
+            router.push(`/dashboard/bookmarks/${bookmark.id}/manage_lists`)
+          }
+          className="flex w-full flex-row justify-between gap-3 rounded-lg"
+        >
+          <Text>Manage Lists</Text>
+          <ChevronRight />
+        </Pressable>
+      </View>
+    </InfoSection>
   );
 }
 
 function TitleEditor({
-  bookmarkId,
   title,
+  setTitle,
+  isPending,
 }: {
-  bookmarkId: string;
-  title: string;
+  title: string | null | undefined;
+  setTitle: (title: string | null) => void;
+  isPending: boolean;
 }) {
-  const { mutate, isPending } = useUpdateBookmark();
   return (
-    <View className="flex gap-4">
+    <InfoSection>
       <Input
         editable={!isPending}
-        multiline={true}
+        multiline={false}
         numberOfLines={1}
-        loading={isPending}
         placeholder="Title"
-        textAlignVertical="top"
-        onEndEditing={(ev) =>
-          mutate({
-            bookmarkId,
-            title: ev.nativeEvent.text ? ev.nativeEvent.text : null,
-          })
-        }
+        onChangeText={(text) => setTitle(text)}
         defaultValue={title ?? ""}
       />
-    </View>
+    </InfoSection>
   );
 }
 
-function NotesEditor({ bookmark }: { bookmark: ZBookmark }) {
-  const { mutate, isPending } = useUpdateBookmark();
+function NotesEditor({
+  notes,
+  setNotes,
+  isPending,
+}: {
+  notes: string | null | undefined;
+  setNotes: (title: string | null) => void;
+  isPending: boolean;
+}) {
   return (
-    <View className="flex gap-4">
+    <InfoSection>
       <Input
         editable={!isPending}
         multiline={true}
-        numberOfLines={3}
-        loading={isPending}
         placeholder="Notes"
+        inputClasses="h-24"
+        onChangeText={(text) => setNotes(text)}
         textAlignVertical="top"
-        onEndEditing={(ev) =>
-          mutate({
-            bookmarkId: bookmark.id,
-            note: ev.nativeEvent.text,
-          })
-        }
-        defaultValue={bookmark.note ?? ""}
+        defaultValue={notes ?? ""}
       />
-    </View>
+    </InfoSection>
   );
 }
 
 const ViewBookmarkPage = () => {
+  const insets = useSafeAreaInsets();
   const { slug } = useLocalSearchParams();
   const { toast } = useToast();
   if (typeof slug !== "string") {
     throw new Error("Unexpected param type");
   }
 
+  const { mutate: editBookmark, isPending: isEditPending } = useUpdateBookmark({
+    onSuccess: () => {
+      toast({
+        message: "The bookmark has been updated!",
+        showProgress: false,
+      });
+      setEditedBookmark({});
+    },
+  });
+
   const { mutate: deleteBookmark, isPending: isDeletionPending } =
     useDeleteBookmark({
       onSuccess: () => {
@@ -149,12 +167,6 @@ const ViewBookmarkPage = () => {
       },
     });
 
-  const keyboard = useAnimatedKeyboard();
-
-  const animatedStyles = useAnimatedStyle(() => ({
-    marginBottom: keyboard.height.value,
-  }));
-
   const {
     data: bookmark,
     isPending,
@@ -163,6 +175,11 @@ const ViewBookmarkPage = () => {
     bookmarkId: slug,
   });
 
+  const [editedBookmark, setEditedBookmark] = React.useState<{
+    title?: string | null;
+    note?: string;
+  }>({});
+
   if (isPending) {
     return <FullPageSpinner />;
   }
@@ -188,6 +205,27 @@ const ViewBookmarkPage = () => {
     );
   };
 
+  const onDone = () => {
+    const doDone = () => {
+      if (router.canGoBack()) {
+        router.back();
+      } else {
+        router.replace("dashboard");
+      }
+    };
+    if (Object.keys(editedBookmark).length === 0) {
+      doDone();
+      return;
+    }
+    Alert.alert("You have unsaved changes", "Do you still want to leave?", [
+      { text: "Cancel", style: "cancel" },
+      {
+        text: "Leave",
+        onPress: doDone,
+      },
+    ]);
+  };
+
   let title = null;
   switch (bookmark.content.type) {
     case BookmarkTypes.LINK:
@@ -201,56 +239,77 @@ const ViewBookmarkPage = () => {
       break;
   }
   return (
-    <View>
-      <Stack.Screen
-        options={{
-          headerShown: true,
-          headerTransparent: false,
-          headerTitle: title ?? "Untitled",
-          headerRight: () => (
-            <Pressable
-              onPress={() => {
-                if (router.canGoBack()) {
-                  router.back();
-                } else {
-                  router.replace("dashboard");
-                }
-              }}
+    <KeyboardGestureArea interpolator="ios">
+      <KeyboardAwareScrollView
+        className="p-4"
+        bottomOffset={8}
+        keyboardDismissMode="interactive"
+        contentContainerStyle={{ paddingBottom: insets.bottom }}
+      >
+        <Stack.Screen
+          options={{
+            headerShown: true,
+            headerTransparent: false,
+            headerTitle: title ?? "Untitled",
+            headerRight: () => (
+              <Pressable onPress={onDone}>
+                <Text>Done</Text>
+              </Pressable>
+            ),
+          }}
+        />
+        <View className="gap-4">
+          <TitleEditor
+            title={title}
+            setTitle={(title) =>
+              setEditedBookmark((prev) => ({ ...prev, title }))
+            }
+            isPending={isEditPending}
+          />
+          <TagList bookmark={bookmark} />
+          <ManageLists bookmark={bookmark} />
+          <NotesEditor
+            notes={bookmark.note}
+            setNotes={(note) =>
+              setEditedBookmark((prev) => ({ ...prev, note: note ?? "" }))
+            }
+            isPending={isEditPending}
+          />
+          <View className="flex justify-between gap-3">
+            <Button
+              onPress={() =>
+                editBookmark({
+                  bookmarkId: bookmark.id,
+                  ...editedBookmark,
+                })
+              }
+              disabled={isEditPending}
             >
-              <Text className="text-foreground">Done</Text>
-            </Pressable>
-          ),
-        }}
-      />
-      <Animated.ScrollView className="p-4" style={[animatedStyles]}>
-        <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
-          <View className="h-screen gap-8 px-2">
-            <TitleEditor bookmarkId={bookmark.id} title={title ?? ""} />
-            <TagList bookmark={bookmark} />
-            <ManageLists bookmark={bookmark} />
-            <NotesEditor bookmark={bookmark} />
+              <Text>Save</Text>
+            </Button>
             <Button
-              onPress={handleDeleteBookmark}
               variant="destructive"
+              onPress={handleDeleteBookmark}
               disabled={isDeletionPending}
-              label="Delete"
-            />
-            <View className="gap-2">
-              <Text className="items-center text-center">
-                Created {bookmark.createdAt.toLocaleString()}
-              </Text>
-              {bookmark.modifiedAt &&
-                bookmark.modifiedAt.getTime() !==
-                  bookmark.createdAt.getTime() && (
-                  <Text className="items-center text-center">
-                    Modified {bookmark.modifiedAt.toLocaleString()}
-                  </Text>
-                )}
-            </View>
+            >
+              <Text>Delete</Text>
+            </Button>
+          </View>
+          <View className="gap-2">
+            <Text className="items-center text-center">
+              Created {bookmark.createdAt.toLocaleString()}
+            </Text>
+            {bookmark.modifiedAt &&
+              bookmark.modifiedAt.getTime() !==
+                bookmark.createdAt.getTime() && (
+                <Text className="items-center text-center">
+                  Modified {bookmark.modifiedAt.toLocaleString()}
+                </Text>
+              )}
           </View>
-        </TouchableWithoutFeedback>
-      </Animated.ScrollView>
-    </View>
+        </View>
+      </KeyboardAwareScrollView>
+    </KeyboardGestureArea>
   );
 };
 
-- 
cgit v1.2.3-70-g09d2

